# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""checks for OWNERS files recursively and only allow commits accordingly.

The idea is that every directory in DIR_ROOT can have a file named OWNERS.
This file determines who has write permissions to that directory, and
every directory beneath it.  Permissions are additive, so you have
permissions to write to file ^/repo/foo/bar/baz if you're listed in
^/repo/OWNERS, ^/repo/foo/OWNERS, or ^/repo/foo/bar/OWNERS.

Format for OWNERS:
# takes comments
# This special line means we don't inherit OWNERS from higher dirs
set noparent
# single login names, one per line, as such:
login
# or groups like so:
group: svngroup1  # and trailing comments are allowed
# or includes like so (you can only include another OWNERS file not randomfile):
file:../some_other_dir/OWNERS

Note that syntax errors are logged, the relevant line ignored, and parsing
continues (i.e. it doesn't prevent access if another line allows it)
"""

import os
import re
import svn.core
import svn.fs
import svn.repos
import gvn.userdb


# Change this for your local install if you don't like "userdb" or want
# to specify a full PATH (we use a relative symlink or a central auth db)
SVN_USERDB_PATH = "conf/gvn/userdb"

# If a committer or approver is in this special group, submit is allowed
# regardless of what the OWNERS file say
# FIXME: this will be replaced in the future with a trigger that can allow
# admins to commit and skip other triggers like this one
OWNERS_OVERRIDE_GROUP = "svn-owners-keepers"

class OwnersCache(object):

  def __init__(self, hi, logger):
    """computes and keeps track of authorized owners and groups.

    Args:
      hi: HookInfo object
      logger: initialized logger object

    This object populates and keeps a dict of directory names to list of
    allowed users and groups
    """

    # Dictionary of directories to a list of 2 strings: logins and groups
    self._dir_cache = {}
    self._hi = hi
    self._logger = logger

  def GetOwnerLoginsGroups(self, path, recursive=True):
    """Computes and returns allowed logins and groups.

    Args:
      path: checks the repository starting at path and working up
            must not have a trailing slash
      recursive: when implemented allows this function to check a given
                 level without working up (for OWNERS includes)
    Returns:
      two dictionaries: one with allowed logins and one with allowed groups

    Method looks for OWNERS in path, and recurses all the way to the svn root
    """

    hi, logger = self._hi, self._logger
    logger.debug("path is " + path)

    if path in self._dir_cache:
      logger.info(path + " cached, returning from _dir_cache")
      return self._dir_cache[path]

    owners_login, owners_group = {}, {}

    # Refuse trailing slashes, except for / of course :)
    assert (path[-1] != "/" or len(path) == 1), \
                              "trailing slash forbidden in GetOwnerLoginsGroups"
    # require a leading slash
    assert (path[0] == "/"), "leading slash required in GetOwnerLoginsGroups"

    owner_file = path + "/OWNERS"
    kind = svn.fs.check_path(hi.head_root, owner_file, hi.pool)

    # by default, we will recurse down after we read this file
    recurse_down = True

    # See subversion/include/svn_types.h for node types
    if kind == svn.core.svn_node_none:
      pass
    elif kind == svn.core.svn_node_file:
      logger.info("Parsing OWNERS " + owner_file)
      owners_contents = svn.core.Stream(
          svn.fs.file_contents(hi.head_root, owner_file, hi.pool) ).read()
      for line in owners_contents.splitlines():
        # remove leading/trailing whitespace
        line=line.strip()
        # remove trailing comments and full line comments
        line = re.sub("#.*", "", line)
        groupline = re.sub("^group: *", "", line)
        fileline = re.sub("^file: *", "", line)

        logger.debug("cleaned up line: "+line)
        if line == "":
          continue
        elif re.match("set\s+noparent", line):
          logger.info("Received noparent, skipping parent owners of "+path)
          recurse_down = False
        elif groupline != line:
          owners_group[groupline] = True
        elif fileline != line:
          directory, filename = os.path.split(fileline)
          if filename != "OWNERS":
            logger.warning("include to '%s' isn't valid, you can only include OWNERS files" % (fileline))
            continue
          logger.error("include file support not implemented, sorry")
          # implementing this would be calling GetOwnerLoginsGroups with
          # recursive=False but you'd have to worry about diamond includes
          # and include loops (i.e. keep a path list).
          # For now, we'll see if our users can do without it
        elif not re.search("\s+", line):
          owners_login[line] = True
        else:
          logger.warning("Can't parse line '%s' in file %s" %
                                                          (line, owner_file))
    else:
      # If we get here, somehow OWNERS exists, but isn't a file (maybe a dir?)
      logger.warning("'%s' is not a file, but is of kind %d" %
                                                          (owner_file, kind))

    path_up = os.path.dirname(path)
    # when path='/', it becomes invariant and we stop recursion
    if path_up and path_up != path and recurse_down:
      up_owners_login, up_owners_groups = self.GetOwnerLoginsGroups(path_up)
      owners_login.update(up_owners_login)
      owners_group.update(up_owners_groups)

    logger.debug("after: %s %s" % (owners_login, owners_group))
    self._dir_cache [path] = ( owners_login, owners_group )
    return owners_login, owners_group

def RunHook(hi, logger, userdb_path=None):
  """Implements gvn.hooks.runner's RunHook interface

  Additional arguments:
    userdb_path: when calling from unittest, allow another test userdb path

  Returns:
    0: pass
    1: fail
    "string": fail and print string to the user
  """

  # which directories we've already checked OWNERS access in
  checked_dir_list = {}
  owner = OwnersCache(hi, logger)
  if userdb_path is None:
    userdb_path = hi.repos_path + "/" + SVN_USERDB_PATH

  userdb = gvn.userdb.UserDB(userdb_path)
  author = svn.fs.txn_prop(hi.txn, "svn:author", hi.pool)
  if author is None:
    retstr = "Error: transaction was committed with empty author property"
    logger.warning(retstr)
    return retstr

  approvers = []
  logger.debug("looking for approvers")
  gvn_prop = svn.fs.txn_prop(hi.txn, "gvn:change", hi.pool)
  if gvn_prop:
    # turn user1/boguschangenme@3 into 3
    gvn_rev = int(re.sub(r".*@", "", gvn_prop))
    props = svn.fs.revision_proplist(hi.fs, gvn_rev, hi.pool)
    logger.debug("got rev %s and props %s" % (gvn_rev, str(props)))
    for key in props:
      # this property is sanity checked by the hook that checks it in
      match = re.match(r"gvn:approve:(.+)", key)
      if match is not None:
        approver = match.group(1)
        logger.debug("Found approver %s in changeset %d" % (approver, gvn_rev))
        approvers.append(approver)
  else:
    logger.debug("No gvn:change in props (%s)" % str(gvn_prop))

  allowed_submitters = approvers
  allowed_submitters.append(author)

  for (path, cl) in hi.paths_changed.iteritems():
    group_match = False

    # if a directory gets a modify request, it's a property change. Directory
    # changes are special since they are controlled by the OWNERS file in that
    # same directory, and not one level up
    if (svn.fs.check_path(hi.head_root, path, hi.pool) == svn.core.svn_node_dir
        and cl.change_kind == svn.fs.path_change_modify):
      directory = path
    else:
      # otherwise, we check OWNERS one level up
      directory = os.path.dirname(path)

    logger.debug("%s in %s changed" % (path, directory))

    if directory in checked_dir_list:
      continue

    checked_dir_list[directory] = True

    logins, groups = owner.GetOwnerLoginsGroups(directory)
    if not logins and not groups:
      logger.info("%s authorized for %s (no OWNERS)" % (author, path))
      continue
    # Add an extra admin group to all owners group dicts
    groups[OWNERS_OVERRIDE_GROUP] = True
    # nice python2.4: find if any user from list1 is in list2
    if set(allowed_submitters).intersection(logins):
      logger.info("%s authorized for %s in OWNERS logins" % (author, path))
      continue
    else:
      for group in groups:
        if group_match:
          break
        for allowed_submitter in allowed_submitters:
          if userdb.UserInGroup(allowed_submitter, group):
            logger.info("%s authorized in OWNERS group %s for %s" %
                                          (allowed_submitter, group, path))

            # I really wanted a continue 3 (3 levels of loop up), or a goto
            # back to the beginning of for path in, but this will have to do
            group_match = True
            break

    if group_match:
      continue

    # So, if we get here, we have a non allowed file and terminate the for
    # loop early
    retstr = ( "%s not authorized in any OWNERS file for %s, sorry"
                                                          % (author, path))
    logger.info(retstr)
    return retstr
  # end for path

  logger.info("All files authorized, success!")
  return 0
